{
 "cells": [
  {
   "cell_type": "code",
   "execution_count": 1,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "<style>/* div {\n",
       "    max-width: 900px !important;\n",
       "    text-align: justify;\n",
       "} */\n",
       "\n",
       "\n",
       "/* .inner_cell {\n",
       "    max-width: 900px !important;\n",
       "    text-align: justify;\n",
       "} */\n",
       "\n",
       ".cell {\n",
       "    /* max-width: 900px !important; */\n",
       "    /* line-height: 1.6em; */\n",
       "    padding-bottom: 15px;\n",
       "    text-align: justify;\n",
       "}\n",
       "\n",
       ".markup {\n",
       "    max-width: 900px !important;\n",
       "    text-align: justify;\n",
       "}\n",
       "\n",
       "img {\n",
       "    display: block;\n",
       "    margin-left: auto;\n",
       "    margin-right: auto;\n",
       "}\n",
       "\n",
       "/* αt */\n",
       "/* βt */\n",
       "/* γt */</style>"
      ],
      "text/plain": [
       "<IPython.core.display.HTML object>"
      ]
     },
     "execution_count": 1,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "# Apply custon style to notebook\n",
    "from IPython.core.display import HTML\n",
    "import pathlib\n",
    "styles_path = pathlib.Path(pathlib.Path().absolute(), \"style\", \"style.css\")\n",
    "styles = open(styles_path, \"r\").read()\n",
    "HTML(f\"<style>{styles}</style>\")"
   ]
  },
  {
   "attachments": {},
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "_Note: This notebook must be run locally. Due to how the Swift Simulator operates, this notebook will not run on Google Colab_"
   ]
  },
  {
   "attachments": {},
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# 4.0 Null-Space Projection for Motion Control\n",
    "\n",
    "$\\large{\\text{Manipulator Differential Kinematics}} \\\\ \\large{\\text{Part II: Acceleration and Advanced Applications}}$\n",
    "\n",
    "$\\text{By Jesse Haviland and Peter Corke}$\n",
    "\n",
    "<br>\n",
    "\n",
    "The sections of the Tutorial paper related to this notebook are listed next to each contents entry.\n",
    "It is beneficial to read these sections of the paper before attempting the notebook Section.\n",
    "\n",
    "### Contents\n",
    "\n",
    "[4.1 Swift and Robot Setup](#swift)\n",
    "\n",
    "[4.2 Null-Space Projection](#nsp)\n",
    "* Advanced Velocity Control\n",
    "  * Null-space Projection\n",
    "\n",
    "[4.3 Manipulability Maximising](#mm)\n",
    "* Advanced Velocity Control\n",
    "  * Null-space Projection\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "metadata": {},
   "outputs": [],
   "source": [
    "# We will do the imports required for this notebook here\n",
    "\n",
    "# numpy provides import array and linear algebra utilities\n",
    "import numpy as np\n",
    "\n",
    "# the robotics toolbox provides robotics specific functionality\n",
    "import roboticstoolbox as rtb\n",
    "\n",
    "# spatial math provides objects for representing transformations\n",
    "import spatialmath as sm\n",
    "\n",
    "# swift is a lightweight browser-based simulator which comes eith the toolbox\n",
    "from swift import Swift\n",
    "\n",
    "# spatialgeometry is a utility package for dealing with geometric objects\n",
    "import spatialgeometry as sg"
   ]
  },
  {
   "attachments": {},
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<br>\n",
    "\n",
    "<a id='swift'></a>\n",
    "\n",
    "### 4.1 Swift and Robot Setup\n",
    "---"
   ]
  },
  {
   "attachments": {},
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "In this notebook we will be using the Robotics Toolbox for Python and Swift to simulate our motion controllers. Check out Part 1 Notebook 3 for an introduction to these packages."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "\n",
       "        <iframe\n",
       "            width=\"600\"\n",
       "            height=\"400\"\n",
       "            src=\"http://localhost:52010/?53010\"\n",
       "            frameborder=\"0\"\n",
       "            allowfullscreen\n",
       "            \n",
       "        ></iframe>\n",
       "        "
      ],
      "text/plain": [
       "<IPython.lib.display.IFrame at 0x2942ce950>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "data": {
      "text/plain": [
       "2"
      ]
     },
     "execution_count": 3,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "# Make the environment\n",
    "env = Swift()\n",
    "\n",
    "# Launch the simulator\n",
    "# The realtime flag will ask the simulator to simulate as close as\n",
    "# possible to realtime as apposed to as fast as possible\n",
    "env.launch(realtime=True, browser=\"notebook\")\n",
    "env.set_camera_pose([1.3, 0, 0.4], [0, 0, 0.3])\n",
    "\n",
    "# Make a panda robot\n",
    "panda = rtb.models.Panda()\n",
    "\n",
    "# Set the joint coordinates to qr\n",
    "panda.q = panda.qr\n",
    "\n",
    "# We can then add our robot to the simulator envionment\n",
    "env.add(panda)\n",
    "\n",
    "# end-effector axes\n",
    "ee_axes = sg.Axes(0.1)\n",
    "\n",
    "# goal axes\n",
    "goal_axes = sg.Axes(0.1)\n",
    "\n",
    "# Add the axes to the environment\n",
    "env.add(ee_axes)\n",
    "env.add(goal_axes) "
   ]
  },
  {
   "attachments": {},
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<br>\n",
    "\n",
    "<a id='nsp'></a>\n",
    "\n",
    "### 4.2 Null-Space Projection\n",
    "---"
   ]
  },
  {
   "attachments": {},
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Many modern manipulators are redundant — they have more than 6 degrees of freedom. We can exploit this redundancy by having the robot optimize a performance measure while still achieving the original goal. In this Section, we start with the resolved-rate motion control (RRMC) algorithm explained in Part 1 Notebook 3\n",
    "\n",
    "\\begin{equation*}\n",
    "     \\dot{\\bf{q}} = \\bf{J}(\\bf{q})^{+} \\ \\bf{\\nu}.\n",
    "\\end{equation*}"
   ]
  },
  {
   "attachments": {},
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "The Jacobian of a redundant manipulator has a null space. Any joint velocity vector which is a linear combination of the manipulator Jacobian's null-space will result in no end-effector motion ($\\bf{\\nu} = 0$). We can augment RRMC to add a joint velocity vector $\\dot{\\bf{q}}_{null}$ which can be projected into the null-space resulting in zero end-effector spatial velocity\n",
    "\n",
    "\\begin{equation*}\n",
    "    \\dot{\\bf{q}} =\n",
    "    \\underbrace{\n",
    "        \\bf{J}(\\bf{q})^{+} \\ \\bf{\\nu}\n",
    "    }_{\\text{end-effector \\ motion}} + \\ \\\n",
    "        \\underbrace{\n",
    "        \\left(\n",
    "            \\bf{1}_n - \\bf{J}(\\bf{q})^{+} \\bf{J}(\\bf{q})\n",
    "        \\right)\n",
    "        \\dot{\\bf{q}}_\\text{null}\n",
    "    }_{\\text{null-space \\ motion}}\n",
    "\\end{equation*}\n",
    "\n",
    "where $\\bf{1}_n \\in \\mathbb{R}^{n \\times n}$ is an identity matrix, and $\\dot{\\bf{q}}_\\text{null}$ is the desired joint velocities for the null-space motion."
   ]
  },
  {
   "attachments": {},
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "We can set $\\dot{\\bf{q}}_\\text{null}$ to be the gradient of any scalar performance measure $\\gamma(\\bf{q})$ -- the performance measure must be a differentiable function of the joint coordinates $\\bf{q}$.\n",
    "\n",
    "Lets make a Python method to do this"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "metadata": {},
   "outputs": [],
   "source": [
    "def null_project(robot, q, qnull, ev, λ):\n",
    "    \"\"\"\n",
    "    Calculates the required joint velocities qd to achieve the desired\n",
    "    end-effector velocity ev while projecting the null-space motion qnull\n",
    "    into the null-space of the jacobian of the robot.\n",
    "\n",
    "    robot: a Robot object (must be redundant with robot.n > 6)\n",
    "    q: the robots current joint coordinates\n",
    "    qnull: the null-space motion to be projected into the solution\n",
    "    ev: the desired end-effector velocity (expressed in the base-frame\n",
    "        of the robot)\n",
    "    λ: a gain to apply to the null-space motion\n",
    "\n",
    "    Note: If you would like to express ev in the end-effector frame,\n",
    "        change the `jacob0` below to `jacobe`\n",
    "    \"\"\"\n",
    "\n",
    "    # Calculate the base-frame manipulator Jacobian\n",
    "    J0 = robot.jacob0(q)\n",
    "\n",
    "    # Calculate the pseudoinverse of the base-frame manipulator Jacobian\n",
    "    J0_pinv = np.linalg.pinv(J0)\n",
    "\n",
    "    # Calculate the joint velocity vector according to the equation above\n",
    "    qd = J0_pinv @ ev + (1.0 / λ) * (np.eye(robot.n) - J0_pinv @ J0) @ qnull.reshape(robot.n,)\n",
    "\n",
    "    return qd"
   ]
  },
  {
   "attachments": {},
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<br>\n",
    "\n",
    "<a id='mm'></a>\n",
    "\n",
    "### 4.3 Manipulability Maximising\n",
    "---"
   ]
  },
  {
   "attachments": {},
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Park [4] proposed using the gradient of the Yoshikawa manipulability index as $\\dot{\\bf{q}}_\\text{null}$. As detailed in Part 1 of this Tutorial, the manipulability index is calculated as\n",
    "\n",
    "\\begin{align*}\n",
    "    m(\\bf{q}) = \\sqrt{\n",
    "        \\text{det}\n",
    "        \\Big(\n",
    "            \\tilde{\\bf{J}}(\\bf{q})\n",
    "            \\tilde{\\bf{J}}(\\bf{q})^\\top\n",
    "        \\Big)\n",
    "    }\n",
    "\\end{align*}\n",
    "\n",
    "where $\\tilde{\\bf{J}}(\\bf{q}) \\in \\mathbb{R}^{3 \\times n}$ is either the translational or rotational rows of $\\bf{J}(\\bf{q})$ causing  $m(\\bf{q})$ to describe the corresponding component of manipulability.\n",
    "\n",
    "Taking the time derivative of $m(\\bf{q})$ using the chain rule\n",
    "\\begin{align*}\n",
    "    \\frac{\\text{d} \\ m(t)}\n",
    "            {\\text{d} t} = \n",
    "    \\dfrac{1}\n",
    "            {2m(t)} \n",
    "    \\frac{\\text{d} \\ \\text{det} \\left( \\tilde{\\bf{J}}(\\bf{q}) \\tilde{\\bf{J}}(\\bf{q})^\\top \\right)}\n",
    "            {\\text{d} t} \n",
    "\\end{align*}\n",
    "\n",
    "we can write this as\n",
    "\n",
    "\\begin{align*}\n",
    "    \\dot{m}\n",
    "    &=\n",
    "    \\bf{J}_m^\\top(\\bf{q}) \\ \\dot{\\bf{q}}\n",
    "\\end{align*}\n",
    "\n",
    "where\n",
    "\n",
    "\\begin{equation*}\n",
    "    \\bf{J}_m^\\top(\\bf{q})\n",
    "    =\n",
    "    \\begin{pmatrix}\n",
    "        m \\, \\text{vec} \\left( \\tilde{\\bf{J}}(\\bf{q}) \\tilde{\\bf{H}}(\\bf{q})_1^\\top \\right)^\\top \n",
    "        \\text{vec} \\left( (\\tilde{\\bf{J}}(\\bf{q})\\tilde{\\bf{J}}(\\bf{q})^\\top)^{-1} \\right) \\\\\n",
    "        m \\, \\text{vec} \\left( \\tilde{\\bf{J}}(\\bf{q}) \\tilde{\\bf{H}}(\\bf{q})_2^\\top \\right)^\\top \n",
    "        \\text{vec} \\left( (\\tilde{\\bf{J}}(\\bf{q})\\tilde{\\bf{J}}(\\bf{q})^\\top)^{-1} \\right) \\\\\n",
    "        \\vdots \\\\\n",
    "        m \\, \\text{vec} \\left( \\tilde{\\bf{J}}(\\bf{q}) \\tilde{\\bf{H}}(\\bf{q})_n^\\top \\right)^\\top \n",
    "        \\text{vec} \\left( (\\tilde{\\bf{J}}(\\bf{q})\\tilde{\\bf{J}}(\\bf{q})^\\top)^{-1} \\right) \\\\\n",
    "    \\end{pmatrix}\n",
    "\\end{equation*}\n",
    "\n",
    "is the manipulability Jacobian $\\bf{J}^\\top_m \\in \\R^n$ and where the vector operation $\\text{vec}(\\cdot) : \\R^{a \\times b} \\rightarrow \\R^{ab}$ converts a matrix column-wise into a vector,\n",
    "and $\\tilde{\\bf{H}}_i \\in \\R^{3 \\times n}$ is the translational or rotational component (matching the choice of $\\tilde{\\bf{J}}(\\bf{q})$)\n",
    "of $\\bf{H}_i \\in \\R^{6 \\times n}$ which is the $i^{th}$ component of the manipulator Hessian tensor $\\bf{H} \\in \\R^{n \\times 6 \\times n}$.\n",
    "\n",
    "The complete equation proposed by Park is\n",
    "\n",
    "\\begin{equation*}\n",
    "    \\dot{\\bf{q}} =\n",
    "    \\bf{J}(\\bf{q})^{+} \\ \\bf{\\nu} +\n",
    "    \\frac{1}{\\lambda} \n",
    "    \\Big(\n",
    "        \\left(\n",
    "            \\bf{1}_n - \\bf{J}(\\bf{q})^{+} \\bf{J}(\\bf{q})\n",
    "        \\right)\n",
    "        \\bf{J_m}(\\bf{q})\n",
    "    \\Big)\n",
    "\\end{equation*}\n",
    "\n",
    "where $\\lambda$ is a gain which scales the magnitude of the null-space velocities. This equation will choose joint velocities $\\dot{\\bf{q}}$ which will achieve the end-effector spatial velocity $\\bf{\\nu}$ while also improving the translational and/or rotational manipulability of the robot.\n",
    "\n",
    "Lets make this in Python\n",
    "\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "metadata": {},
   "outputs": [],
   "source": [
    "def jacobm(robot, q, axes):\n",
    "    \"\"\"\n",
    "    Calculates the manipulability Jacobian. This measure relates the rate\n",
    "    of change of the manipulability to the joint velocities of the robot.\n",
    "\n",
    "    q: The joint angles/configuration of the robot\n",
    "    axes: A boolean list which correspond with the Cartesian axes to\n",
    "        find the manipulability Jacobian of (6 boolean values in a list)\n",
    "\n",
    "    returns the manipulability Jacobian\n",
    "    \"\"\"\n",
    "\n",
    "    # Calculate the base-frame manipulator Jacobian\n",
    "    J0 = robot.jacob0(q)\n",
    "\n",
    "    # only keep the selected axes of J\n",
    "    J0 = J0[axes, :]\n",
    "\n",
    "    # Calculate the base-frame manipulator Hessian\n",
    "    H0 = robot.hessian0(q)\n",
    "\n",
    "    # only keep the selected axes of H\n",
    "    H0 = H0[:, axes, :]\n",
    "\n",
    "    # Calculate the manipulability of the robot\n",
    "    manipulability = np.sqrt(np.linalg.det(J0 @ J0.T))\n",
    "\n",
    "    # Calculate component of the Jacobian\n",
    "    b = np.linalg.inv(J0 @ J0.T)\n",
    "\n",
    "    # Allocate manipulability Jacobian\n",
    "    Jm = np.zeros((robot.n, 1))\n",
    "\n",
    "    # Calculate manipulability Jacobian\n",
    "    for i in range(robot.n):\n",
    "        c = J0 @ H0[i, :, :].T\n",
    "        Jm[i, 0] = manipulability * (c.flatten(\"F\")).T @ b.flatten(\"F\")\n",
    "\n",
    "    return Jm"
   ]
  },
  {
   "attachments": {},
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "As with RRMC, Park's method will provide the joint velocities for a desired end-effector velocity. As we did in Part 1, we can employ this in a position-based servoing (PBS) controller to get the end-effector to travel towards some desired pose. The PBS scheme is\n",
    "\n",
    "\\begin{align*}\n",
    "    \\bf{\\nu} = \\bf{k} \\bf{e}\n",
    "\\end{align*}\n",
    "\n",
    "where $\\bf{\\nu}$ is the desired end-effector spatial velocity to be used."
   ]
  },
  {
   "attachments": {},
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Lets make a try the PBS scheme while maximising the translational manipulability"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "\n",
       "        <iframe\n",
       "            width=\"600\"\n",
       "            height=\"400\"\n",
       "            src=\"http://localhost:52011/?53011\"\n",
       "            frameborder=\"0\"\n",
       "            allowfullscreen\n",
       "            \n",
       "        ></iframe>\n",
       "        "
      ],
      "text/plain": [
       "<IPython.lib.display.IFrame at 0x2944e4190>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# Make a new environment and add objects\n",
    "env = Swift()\n",
    "env.launch(realtime=True, browser=\"notebook\")\n",
    "env.set_camera_pose([1.3, 0, 0.4], [0, 0, 0.3])\n",
    "env.add(panda)\n",
    "env.add(ee_axes)\n",
    "env.add(goal_axes) \n",
    "\n",
    "# Change the robot configuration to a ready position\n",
    "panda.q = [0.21, -0.03, 0.35, -1.90, -0.04, 1.96, 1.36]\n",
    "\n",
    "# Only the translation axes\n",
    "trans_axes = [True, True, True, False, False, False]\n",
    "\n",
    "# Only the rotation aces\n",
    "rot_axes = [False, False, False, True, True, True]\n",
    "\n",
    "# All axes\n",
    "all_axes = [True, True, True, True, True, True]\n",
    "\n",
    "# Step the sim to view the robot in this configuration\n",
    "env.step(0)\n",
    "\n",
    "# A variable to specify when to break the loop\n",
    "arrived = False\n",
    "\n",
    "# Specify our timestep\n",
    "dt = 0.05\n",
    "\n",
    "Tep = (\n",
    "    panda.fkine(panda.q)\n",
    "    * sm.SE3.Tx(-0.1)\n",
    "    * sm.SE3.Ty(0.6)\n",
    "    * sm.SE3.Tz(0.4)\n",
    ")\n",
    "Tep = Tep.A\n",
    "\n",
    "# Set the goal axes to Tep\n",
    "goal_axes.T = Tep\n",
    "\n",
    "# Set the gain on the manipulability maximisation\n",
    "λ = 0.1\n",
    "\n",
    "# Specify the gain for the p_servo method\n",
    "kt = 1.0\n",
    "kr = 1.3\n",
    "k = np.array([kt, kt, kt, kr, kr, kr])\n",
    "\n",
    "# Run the simulation until the robot arrives at the goal\n",
    "while not arrived:\n",
    "\n",
    "    # Work out the base frame manipulator Jacobian using the current robot configuration\n",
    "    J = panda.jacob0(panda.q)\n",
    "\n",
    "    # Calculate the desired null-space motion\n",
    "    qnull = jacobm(panda, panda.q, trans_axes)\n",
    "\n",
    "    # The end-effector pose of the panda (using .A to get a numpy array instead of an SE3 object)\n",
    "    Te = panda.fkine(panda.q).A\n",
    "\n",
    "    # Calculate the required end-effector velocity and whether the robot has arrived\n",
    "    ev, arrived = rtb.p_servo(Te, Tep, gain=k, threshold=0.001, method=\"angle-axis\")\n",
    "\n",
    "    # Calculate the required joint velocities and apply to the robot\n",
    "    panda.qd = null_project(panda, panda.q, qnull, ev, λ)\n",
    "\n",
    "    # Update the ee axes\n",
    "    ee_axes.T = Te\n",
    "\n",
    "    # Step the simulator by dt seconds\n",
    "    env.step(dt)"
   ]
  },
  {
   "attachments": {},
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Lets make a try again while maximising the angular manipulability"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "\n",
       "        <iframe\n",
       "            width=\"600\"\n",
       "            height=\"400\"\n",
       "            src=\"http://localhost:52012/?53012\"\n",
       "            frameborder=\"0\"\n",
       "            allowfullscreen\n",
       "            \n",
       "        ></iframe>\n",
       "        "
      ],
      "text/plain": [
       "<IPython.lib.display.IFrame at 0x2944e7550>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# Make a new environment and add objects\n",
    "env = Swift()\n",
    "env.launch(realtime=True, browser=\"notebook\")\n",
    "env.set_camera_pose([1.3, 0, 0.4], [0, 0, 0.3])\n",
    "env.add(panda)\n",
    "env.add(ee_axes)\n",
    "env.add(goal_axes) \n",
    "\n",
    "# Change the robot configuration to a ready position\n",
    "panda.q = [0.21, -0.03, 0.35, -1.90, -0.04, 1.96, 1.36]\n",
    "\n",
    "# Step the sim to view the robot in this configuration\n",
    "env.step(0)\n",
    "\n",
    "# A variable to specify when to break the loop\n",
    "arrived = False\n",
    "\n",
    "# Run the simulation until the robot arrives at the goal\n",
    "while not arrived:\n",
    "\n",
    "    # Work out the base frame manipulator Jacobian using the current robot configuration\n",
    "    J = panda.jacob0(panda.q)\n",
    "\n",
    "    # Calculate the desired null-space motion\n",
    "    qnull = jacobm(panda, panda.q, rot_axes)\n",
    "\n",
    "    # The end-effector pose of the panda (using .A to get a numpy array instead of an SE3 object)\n",
    "    Te = panda.fkine(panda.q).A\n",
    "\n",
    "    # Calculate the required end-effector velocity and whether the robot has arrived\n",
    "    ev, arrived = rtb.p_servo(Te, Tep, gain=k, threshold=0.001, method=\"angle-axis\")\n",
    "\n",
    "    # Calculate the required joint velocities and apply to the robot\n",
    "    panda.qd = null_project(panda, panda.q, qnull, ev, λ)\n",
    "\n",
    "    # Update the ee axes\n",
    "    ee_axes.T = Te\n",
    "\n",
    "    # Step the simulator by dt seconds\n",
    "    env.step(dt)"
   ]
  }
 ],
 "metadata": {
  "interpreter": {
   "hash": "528815e074ebcdb9b34bcb695d4aa9d425bdb2cc6656b4ca45050b51a4125937"
  },
  "kernelspec": {
   "display_name": "Python 3.8.10 ('rtb')",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.11.0"
  },
  "orig_nbformat": 4
 },
 "nbformat": 4,
 "nbformat_minor": 2
}
